docker 容器内运行多进程踩坑

为什么在一个Docker中运行多个程序进程?

Docker在进程管理上有一些特殊之处,如果不注意这些细节中就会带来一些隐患。另外Docker鼓励“一个容器一个进程(one process per container)”的方式。这种方式非常适合以单进程为主的微服务架构的应用。然而由于一些传统的应用是由若干紧耦合的多个进程构成的,这些进程难以拆分到不同的容器中,所以在单个容器内运行多个进程便成了一种折衷方案;此外在一些场景中,用户期望利用Docker容器来作为轻量级的虚拟化方案,动态的安装配置应用,这也需要在容器中运行多个进程。而在Docker容器中的正确运行多进程应用将给开发者带来更多的挑战。

如何在一个Docker中运行多个程序进程?

基本思路是在Dockerfile 的CMD 或者 ENTRYPOINT 运行一个”东西”,然后再让这个”东西”运行多个其他进程
简单说来是用Bash Shell脚本或者三方进程守护 (Monit,Skaware S6,Supervisor),其他没讲到的三方进程守护工具同理。

docker内运行多进程问题

一 孤儿进程与僵尸进程管理

当一个子进程终止后,它首先会变成一个“失效(defunct)”的进程,也称为“僵尸(zombie)”进程,等待父进程或系统收回(reap)。在Linux内核中维护了关于“僵尸”进程的一组信息(PID,终止状态,资源使用信息),从而允许父进程能够获取有关子进程的信息。如果不能正确回收“僵尸”进程,那么他们的进程描述符仍然保存在系统中,系统资源会缓慢泄露。

大多数设计良好的多进程应用可以正确的收回僵尸子进程,比如NGINX master进程可以收回已终止的worker子进程。如果需要自己实现,则可利用如下方法:

  1. 利用操作系统的waitpid()函数等待子进程结束并请除它的僵死进程,
  2. 由于当子进程成为“defunct”进程时,父进程会收到一个SIGCHLD信号,所以我们可以在父进程中指定信号处理的函数来忽略SIGCHLD信号,或者自定义收回处理逻辑。

如果父进程已经结束了,那些依然在运行中的子进程会成为“孤儿(orphaned)”进程。在Linux中Init进程(PID1)作为所有进程的父进程,会维护进程树的状态,一旦有某个子进程成为了“孤儿”进程后,init就会负责接管这个子进程。当一个子进程成为“僵尸”进程之后,如果其父进程已经结束,init会收割这些“僵尸”,释放PID资源。

然而由于Docker容器的PID1进程是容器启动进程,它们会如何处理那些“孤儿”进程和“僵尸”进程?

二 进程的高可用,进程异常结束后如何恢复。

单进程的容器进程挂掉后整个容器也会停止。但多进程的如果遇见这样的情况
:第一个进程负责正常的对外工作,第二个进程是一个被第一个进程调用的常驻程序(或者为第一个进程提供些库的更新),不能停止,停止后会影响第一个进程的正常工作。

1、用/bin/sh 或者/bin/bash作为PID1进程,这是因为sh/bash等应用可以自动清理僵尸进程。Bash/sh等缺省提供了进程管理能力,如果需要可以作为PID1进程来实现正确的进程回收。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
19:33 $ sudo docker exec -it ditto_cron bash
[root@02f08adf3cd6 s]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 19:33 ? 00:00:00 /bin/bash /home/s/script/start.sh
root 16 1 0 19:33 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 43 1 10 19:33 ? 00:00:01 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 50 0 0 19:33 ? 00:00:00 bash
root 73 50 0 19:33 ? 00:00:00 ps -ef
[root@02f08adf3cd6 s]# kill 41
[root@02f08adf3cd6 s]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 19:33 ? 00:00:00 /bin/bash /home/s/script/start.sh
root 16 1 0 19:33 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 43 1 6 19:33 ? 00:00:01 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 50 0 0 19:33 ? 00:00:00 bash
root 83 50 0 19:33 ? 00:00:00 ps -ef

但是这种需要CMD或者ENTRYPOINT采用exec形式:

1
CMD ["可执行文件", "参数1", "参数2"...]

另一种格式是shell格式

1
CMD <命令>

exec 格式会让/bin/bash 成为1号进程,而shell格式会让后面的命令行成为1号进程。

这种方法可以解决掉僵尸进程的问题,但是进程的高可用需要增加脚本实现。

2、使用Supervisor

Supervisor是一个C/S架构进程管理工具,通过它可以监控和控制其他的进程。可以处理僵尸进程的问题及SIGTERM信号。
在Linux系统启动之后,第一个启动的用户态进程是/sbin/init ,它的PID是1,其余用户态的进程都是init进程的子进程。Supervisor在Docker容器里面充当的就类似init进程的角色,其它的应用进程都是Supervisor进程的子进程。通过这种方法就可以实现在一个容器中启动运行多个应用,。

1
2
3
4
5
6
7
8
9
[root@1e7babdbf192 s]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 20:21 ? 00:00:00 /usr/bin/python /usr/bin/supervisord -c /etc/supervisord.conf
root 7 1 1 20:21 ? 00:00:01 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 8 1 0 20:21 ? 00:00:00 /bin/bash /home/s/script/check_dconf.sh
root 30 1 0 20:21 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 214 0 0 20:22 ? 00:00:00 bash
root 247 8 0 20:22 ? 00:00:00 sleep 10
root 248 214 0 20:22 ? 00:00:00 ps -ef

但要注意一点:supervisor只能管理到前台进程,对于一般的服务,没有终端的进程supervisor无法管理。
除非是把这种进程放入一个脚本中,让这个脚本前台运行并且检测该进程的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/bin/bash

EXEC="/home/s/dconf_reload/src/dconf_main.php"
PROG=`basename $EXEC`
LogPath="/home/s/dconf_reload/log"
Log="${LogPath}/check_dconf.log.`date +%F`"

check()
{
#判断指定进程是否存在
result=`ps -ef | grep -w $PROG | grep -v grep | wc -l`
if [ $result -le 0 ]; then
#不存在, 启动
/bin/bash /home/s/dconf_reload/bin/dctl check ditto >/dev/null 2>&1
sleep 2
echo "`date +'%Y-%m-%d %H:%M:%S'` restart dconf" >> $Log
#ps axuwwww | grep scan_unit | grep avast | grep -v grep | awk '{print $2}' | xargs kill -9

else
#存在,判断状态
#取进程状态,用来判断是否僵死
val=`ps aux | grep $PROG | grep -v grep | awk '{print $8}'`
if [ "$val" == "Zs" ];then
# 取进程ID,用来kill掉进程
pid = `ps -aux | grep $PROG | grep -v grep | awk '{print $2}'`
kill -9 $pid
echo "`date +'%Y-%m-%d %H:%M:%S'` <defunct> process ..." >> $Log
exit 1
else
sleep 10
echo "`date +'%Y-%m-%d %H:%M:%S'` sleep 10" >> $Log
fi
fi
}

while true
do

check

done

supervisor.conf 配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
[unix_http_server]
file=/var/run/supervisor/supervisor.sock ; (the path to the socket file)
chmod=0700 ; sockef file mode (default 0700)

[inet_http_server]
port:127.0.0.1:9001

[supervisord]
pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log)
nodaemon=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
loglevel=debug


[supervisorctl]
serverurl=http://127.0.0.1:9001

[program:check_dconf]
user=root
command=/home/s/script/check_dconf.sh
autostart=true
autorestart=true
startsecs=1
stopsignal=INT

[program:check_ditto]
user=root
command=/home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
autostart=true
autorestart=true
startsecs=1
stdout_logfile=/home/s/scanService/log/stdout.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=10
stdout_capture_maxbytes=1MB
stderr_logfile=/home/s/scanService/log/stderr.log
stderr_logfile_maxbytes=10MB
stderr_logfile_backups=10
stderr_capture_maxbytes=1MB
stopsignal=INT

supervisor 在多进程的情况如果都是前台进程会很好用,因为它解决了僵尸进程和高可用两个问题。但如果有后台程序的话处理就要配合脚本实现。

3、使用monit

  Monit是一个轻量级(500KB)跨平台的用来监控Unix/linux系统的开源工具。部署简单,并且不依赖任何第三方程序、插件或者库。

  Monit可以监控服务器进程、文件、文件系统、网络状态(HTTP/SMTP等协议)、远程主机、服务器资源变化等等。 并且可以设定资源变化后需要做的动作,比如服务失败后自动重启,邮件告警等等。
  
相对于supervisor而言,monit的功能更为强大,不仅可以管理前台、后台进程,而且还能监控文件系统,网络的资源。这里不详细讲解monit的安装使用。只贴下monit的配置

 /etc/monit.conf 主配置文件

  /etc/monit.d/ 各项服务单独配置文件路径,在主配置文件中将其include进来。

monit.conf monit卓配置

1
2
3
4
5
6
7
set daemon  30              # check services at 30 seconds intervals
set log syslog
set httpd port 2812 and
use address localhost # only accept connection from localhost
allow localhost # allow localhost to connect to the server and
allow admin:monit # require user 'admin' with password 'monit'
include /etc/monit.d/*

dconf.conf 配置,需提供dconf的启动脚本和停止脚本

1
2
3
pheck process dconf with MATCHING  dconf_main.php
start "/bin/bash -c /home/s/script/start_dconf.sh"
stop "/bin/bash -c /home/s/script/stop_dconf.sh"

ditto.conf 配置,,需提供ditto的启动脚本和停止脚本

1
2
3
4
5
6
check process ditto with MATCHING  scanService
start "/bin/bash -c /home/s/script/start_ditto.sh"
stop "/bin/bash -c /home/s/script/stop_ditto.sh"
if failed
port 9234 3 cycles
then restart

monit 提供了前台运行方式,解决了多进程不管是前台运行还是后台运行,还有进程高可用的的问题。然而不幸的是,monit没有提供管理僵尸进程(回收子进程)问题的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
10:36 $ sudo docker exec -it ditto_monit bash
[root@152b5b9b6423 s]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:43 ? 00:00:00 /usr/bin/monit -I
root 14 1 10 10:43 ? 00:00:01 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 35 1 0 10:43 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 85 0 1 10:43 ? 00:00:00 bash
root 97 85 0 10:43 ? 00:00:00 ps -ef
[root@152b5b9b6423 s]# kill 14
[root@152b5b9b6423 s]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:43 ? 00:00:00 /usr/bin/monit -I
root 14 1 5 10:43 ? 00:00:01 [ditto] <defunct>
root 35 1 0 10:43 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 85 0 0 10:43 ? 00:00:00 bash
root 108 35 68 10:44 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 109 85 0 10:44 ? 00:00:00 ps -ef

所以需要加入一个脚本,这个脚本运行为pid为1的进程,负责回收处理。
my_init

1
2
3
4
5
6
7
root         1     0  0 21:37 ?        00:00:00 /usr/bin/python2.6 /home/s/script/my_init -- /usr/bin/monit -I
root 8 1 0 21:37 ? 00:00:00 /usr/bin/monit -I
root 16 1 4 21:37 ? 00:00:01 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 32 1 0 21:37 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 97 0 0 21:38 ? 00:00:00 bash
root 118 32 85 21:38 ? 00:00:00 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 119 97 0 21:38 ? 00:00:00 ps -ef

不采用my_init 这种第三方的程序,自己实现子进程的回收处理及信号处理也可以。

docker 高版本在提供了解决方案 在run时加入–init参数可以在容器内部启动一个init 进程作为1号进程,
但是低版本的docker无此功能。

1
2
3
[jinri@23v update]$ docker run --help|grep init
--health-start-period duration Start period for the container to initialize before starting health-retries countdown (ms|s|m|h) (default 0s)
--init Run an init inside the container that forwards signals and reaps processes

另外如果使用的是centos7的镜像还可以使用系统自带的systemd作为容器中的1号进程。它提供进程的自启和信号处理等工作。

最终采用方案:

使用 /bin/bash + crond 的方式

/bin/bash 实现子进程的回收,crond实现对 dconf的高可用监控重启

1
2
3
4
5
6
7
8
UID        PID  PPID  C STIME TTY          TIME CMD
root 1 0 0 15:13 pts/0 00:00:00 /bin/bash /home/s/script/start.sh
root 21 1 0 15:13 pts/0 00:00:01 php /home/s/dconf_reload/bin/../src/dconf_main.php ditto 1 3
root 46 1 0 15:13 ? 00:00:00 crond
root 48 1 0 15:13 pts/0 00:00:02 /home/s/scanService/ditto worker --config /home/s/scanService/conf/config.yaml
root 1401 0 0 15:31 pts/1 00:00:00 bash
root 4438 0 2 16:11 pts/2 00:00:00 bash
root 4450 4438 0 16:11 pts/2 00:00:00 ps -ef

参考链接

理解Docker容器的进程管理

Monit 简介

docker 和pid 1 僵尸进程问题

一个容器多个进程